﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace Ckknight.StateMachine
{
    /// <summary>
    /// Represents a finite-state machine.
    /// 
    /// This is not to be considered thread-safe, but multiple state machines generated from the same builder can be used by multiple different threads without issue.
    /// </summary>
    /// <typeparam name="TState">The type used to represent the state of the machine.</typeparam>
    /// <typeparam name="TTrigger">The type used to represent triggers which cause transitions within the machine.</typeparam>
    /// <typeparam name="TModel">The type which the machine will carry with it and can be used to store information or react differently.</typeparam>
    public sealed partial class StateMachine<TState, TTrigger, TModel>
    {
        #region Private fields

        private readonly TModel _model;
        private readonly Func<TState> _getState;
        private readonly Action<TState> _setState;
        private readonly FrozenDictionary<TState, StateData> _stateToData;
        private readonly IEqualityComparer<TState> _stateComparer;
        private readonly IEqualityComparer<TTrigger> _triggerComparer;
        private readonly Action<Transition> _unhandledTriggerHandler;
        private readonly FrozenDictionary<TTrigger, Type[]> _triggerParameterTypes;

        #endregion

        #region Internal constructor

        /// <summary>
        /// Instantiate a state machine.
        /// </summary>
        /// <param name="model">The model to attach to this state machine.</param>
        /// <param name="getState">A function to get the current state.</param>
        /// <param name="setState">A function to set the current state.</param>
        /// <param name="stateToData">A dictionary of states to the internal metadata structure the state machine uses.</param>
        /// <param name="stateComparer">A comparer to test whether two states are equivalent.</param>
        /// <param name="triggerComparer">A comparer to test whether two triggers are equivalent.</param>
        /// <param name="unhandledTriggerHandler">A function to handle unhandled triggers. Can be null.</param>
        /// <param name="triggerParameterTypes">A dictionary of triggers to an array of types it expects as parameters.</param>
        internal StateMachine(TModel model, Func<TState> getState, Action<TState> setState, FrozenDictionary<TState, StateData> stateToData, IEqualityComparer<TState> stateComparer, IEqualityComparer<TTrigger> triggerComparer, Action<Transition> unhandledTriggerHandler, FrozenDictionary<TTrigger, Type[]> triggerParameterTypes)
        {
            _model = model;
            _getState = getState;
            _setState = setState;
            _stateToData = stateToData;
            _stateComparer = stateComparer;
            _triggerComparer = triggerComparer;
            _unhandledTriggerHandler = unhandledTriggerHandler;
            _triggerParameterTypes = triggerParameterTypes;
        }

        #endregion

        #region Public properties

        /// <summary>
        /// The model attached to the current machine. If not specified, this should be null.
        /// </summary>
        public TModel Model
        {
            get
            {
                return _model;
            }
        }

        /// <summary>
        /// The current state of the machine.
        /// </summary>
        public TState State
        {
            get
            {
                return _getState();
            }
            private set
            {
                _setState(value);
            }
        }

        private static readonly TTrigger[] _emptyTriggerArray = new TTrigger[0];
        /// <summary>
        /// A collection of triggers which are considered legal for the machine's current state.
        /// </summary>
        public ICollection<TTrigger> PermittedTriggers
        {
            get
            {
                TState currentState = State;

                StateData data;
                if (!_stateToData.TryGetValue(currentState, out data))
                {
                    return _emptyTriggerArray;
                }

                return data.Transitions.Keys;
            }
        }

        #endregion

        #region Public methods

        /// <summary>
        /// Return whether the provided state is the current state or one of the current state's parents.
        /// </summary>
        /// <param name="state">The state to check for.</param>
        /// <returns>True if the machine is in the provided state; false otherwise.</returns>
        public bool IsInState(TState state)
        {
            if (state == null)
            {
                return false;
            }

            TState currentState = State;

            if (_stateComparer.Equals(currentState, state))
            {
                return true;
            }

            StateData data;
            if (!_stateToData.TryGetValue(currentState, out data))
            {
                return false;
            }

            return data.Parents.Contains(state, _stateComparer);
        }

        /// <summary>
        /// Return whether the given trigger can be fired legally in the current state of the machine.
        /// </summary>
        /// <remarks>
        /// It is acceptable to pass in triggers that were not configured by the builder, they will merely cause false to be returned.
        /// </remarks>
        /// <param name="trigger">The trigger to check legality for</param>
        /// <returns>True if the given trigger can be fired legally; false otherwise</returns>
        public bool CanFire(TTrigger trigger)
        {
            if (trigger == null)
            {
                return false;
            }
            TState currentState = State;

            StateData data;
            return _stateToData.TryGetValue(currentState, out data)
                && (data.Transitions.ContainsKey(trigger)
                    || data.Ignores.Contains(trigger, _triggerComparer));
        }

        /// <summary>
        /// Fire the given trigger and cause a transition to occur on the current machine.
        /// </summary>
        /// <remarks>
        /// Unless the trigger is ignored by the machine's current state, this will invoke all appropriate OnEnter and OnExit functions.
        /// </remarks>
        /// <param name="trigger">The trigger to fire.</param>
        /// <exception cref="System.ArgumentNullException">
        /// Thrown when <paramref name="trigger"/> is null.
        /// </exception>
        /// <exception cref="System.ArgumentException">
        /// Thrown when <paramref name="trigger"/> has trigger parameters or <paramref name="trigger"/> is not legal and the machine does not have an OnUnhandledTrigger specified.
        /// </exception>
        public void Fire(TTrigger trigger)
        {
            if (trigger == null)
            {
                throw new ArgumentNullException("trigger");
            }

            Type[] typeArray;
            if (_triggerParameterTypes.TryGetValue(trigger, out typeArray))
            {
                throw new ArgumentException(string.Format("Expected {0} parameters, received 0", typeArray.Length), "trigger");
            }

            InternalFire(new Transition(this, trigger, this.State));
        }

        /// <summary>
        /// Fire the given trigger and cause a transition to occur on the current machine.
        /// </summary>
        /// <remarks>
        /// Unless the trigger is ignored by the machine's current state, this will invoke all appropriate OnEnter and OnExit functions.
        /// </remarks>
        /// <param name="trigger">The trigger to fire.</param>
        /// <param name="item1">The first trigger parameter</param>
        /// <typeparam name="TArg1">The first trigger parameter type</typeparam>
        /// <exception cref="System.ArgumentNullException">
        /// Thrown when <paramref name="trigger"/> is null.
        /// </exception>
        /// <exception cref="System.ArgumentException">
        /// Thrown when <paramref name="trigger"/> has differing trigger parameters or <paramref name="trigger"/> is not legal and the machine does not have an OnUnhandledTrigger specified.
        /// </exception>
        public void Fire<TArg1>(TTrigger trigger, TArg1 item1)
        {
            if (trigger == null)
            {
                throw new ArgumentNullException("trigger");
            }

            Type[] typeArray;
            if (!_triggerParameterTypes.TryGetValue(trigger, out typeArray) || typeArray.Length != 1)
            {
                throw new ArgumentException(string.Format("Expected {0} parameters, received 1", typeArray.Length), "trigger");
            }
            else if (typeArray[0] != typeof(TArg1))
            {
                throw new ArgumentException(string.Format("Expected 1st type parameter of {0}, got {1}", typeArray[0].FullName, typeof(TArg1).FullName), "item1");
            }

            InternalFire(new Transition<TArg1>(this, trigger, this.State, item1));
        }

        /// <summary>
        /// Fire the given trigger and cause a transition to occur on the current machine.
        /// </summary>
        /// <remarks>
        /// Unless the trigger is ignored by the machine's current state, this will invoke all appropriate OnEnter and OnExit functions.
        /// </remarks>
        /// <param name="trigger">The trigger to fire.</param>
        /// <param name="item1">The first trigger parameter</param>
        /// <param name="item2">The second trigger parameter</param>
        /// <typeparam name="TArg1">The first trigger parameter type</typeparam>
        /// <typeparam name="TArg2">The second trigger parameter type</typeparam>
        /// <exception cref="System.ArgumentNullException">
        /// Thrown when <paramref name="trigger"/> is null.
        /// </exception>
        /// <exception cref="System.ArgumentException">
        /// Thrown when <paramref name="trigger"/> has differing trigger parameters or <paramref name="trigger"/> is not legal and the machine does not have an OnUnhandledTrigger specified.
        /// </exception>
        public void Fire<TArg1, TArg2>(TTrigger trigger, TArg1 item1, TArg2 item2)
        {
            if (trigger == null)
            {
                throw new ArgumentNullException("trigger");
            }

            Type[] typeArray;
            if (!_triggerParameterTypes.TryGetValue(trigger, out typeArray) || typeArray.Length != 2)
            {
                throw new ArgumentException(string.Format("Expected {0} parameters, received 2", typeArray.Length), "trigger");
            }
            else if (typeArray[0] != typeof(TArg1))
            {
                throw new ArgumentException(string.Format("Expected 1st type parameter of {0}, got {1}", typeArray[0].FullName, typeof(TArg1).FullName), "item1");
            }
            else if (typeArray[1] != typeof(TArg2))
            {
                throw new ArgumentException(string.Format("Expected 2nd type parameter of {0}, got {1}", typeArray[1].FullName, typeof(TArg2).FullName), "item2");
            }

            InternalFire(new Transition<TArg1, TArg2>(this, trigger, this.State, item1, item2));
        }

        /// <summary>
        /// Fire the given trigger and cause a transition to occur on the current machine.
        /// </summary>
        /// <remarks>
        /// Unless the trigger is ignored by the machine's current state, this will invoke all appropriate OnEnter and OnExit functions.
        /// </remarks>
        /// <param name="trigger">The trigger to fire.</param>
        /// <param name="item1">The first trigger parameter</param>
        /// <param name="item2">The second trigger parameter</param>
        /// <param name="item3">The third trigger parameter</param>
        /// <typeparam name="TArg1">The first trigger parameter type</typeparam>
        /// <typeparam name="TArg2">The second trigger parameter type</typeparam>
        /// <typeparam name="TArg3">The third trigger parameter type</typeparam>
        /// <exception cref="System.ArgumentNullException">
        /// Thrown when <paramref name="trigger"/> is null.
        /// </exception>
        /// <exception cref="System.ArgumentException">
        /// Thrown when <paramref name="trigger"/> has differing trigger parameters or <paramref name="trigger"/> is not legal and the machine does not have an OnUnhandledTrigger specified.
        /// </exception>
        public void Fire<TArg1, TArg2, TArg3>(TTrigger trigger, TArg1 item1, TArg2 item2, TArg3 item3)
        {
            if (trigger == null)
            {
                throw new ArgumentNullException("trigger");
            }

            Type[] typeArray;
            if (!_triggerParameterTypes.TryGetValue(trigger, out typeArray) || typeArray.Length != 3)
            {
                throw new ArgumentException(string.Format("Expected {0} parameters, received 3", typeArray.Length), "trigger");
            }
            else if (typeArray[0] != typeof(TArg1))
            {
                throw new ArgumentException(string.Format("Expected 1st type parameter of {0}, got {1}", typeArray[0].FullName, typeof(TArg1).FullName), "item1");
            }
            else if (typeArray[1] != typeof(TArg2))
            {
                throw new ArgumentException(string.Format("Expected 2nd type parameter of {0}, got {1}", typeArray[1].FullName, typeof(TArg2).FullName), "item2");
            }
            else if (typeArray[2] != typeof(TArg3))
            {
                throw new ArgumentException(string.Format("Expected 3rd type parameter of {0}, got {1}", typeArray[2].FullName, typeof(TArg3).FullName), "item3");
            }

            InternalFire(new Transition<TArg1, TArg2, TArg3>(this, trigger, this.State, item1, item2, item3));
        }

        /// <summary>
        /// Fire the given trigger and cause a transition to occur on the current machine.
        /// </summary>
        /// <remarks>
        /// Unless the trigger is ignored by the machine's current state, this will invoke all appropriate OnEnter and OnExit functions.
        /// </remarks>
        /// <param name="trigger">The trigger to fire.</param>
        /// <param name="item1">The first trigger parameter</param>
        /// <param name="item2">The second trigger parameter</param>
        /// <param name="item3">The third trigger parameter</param>
        /// <param name="item4">The fourth trigger parameter</param>
        /// <typeparam name="TArg1">The first trigger parameter type</typeparam>
        /// <typeparam name="TArg2">The second trigger parameter type</typeparam>
        /// <typeparam name="TArg3">The third trigger parameter type</typeparam>
        /// <typeparam name="TArg4">The fourth trigger parameter type</typeparam>
        /// <exception cref="System.ArgumentNullException">
        /// Thrown when <paramref name="trigger"/> is null.
        /// </exception>
        /// <exception cref="System.ArgumentException">
        /// Thrown when <paramref name="trigger"/> has differing trigger parameters or <paramref name="trigger"/> is not legal and the machine does not have an OnUnhandledTrigger specified.
        /// </exception>
        public void Fire<TArg1, TArg2, TArg3, TArg4>(TTrigger trigger, TArg1 item1, TArg2 item2, TArg3 item3, TArg4 item4)
        {
            if (trigger == null)
            {
                throw new ArgumentNullException("trigger");
            }

            Type[] typeArray;
            if (!_triggerParameterTypes.TryGetValue(trigger, out typeArray) || typeArray.Length != 4)
            {
                throw new ArgumentException(string.Format("Expected {0} parameters, received 4", typeArray.Length), "trigger");
            }
            else if (typeArray[0] != typeof(TArg1))
            {
                throw new ArgumentException(string.Format("Expected 1st type parameter of {0}, got {1}", typeArray[0].FullName, typeof(TArg1).FullName), "item1");
            }
            else if (typeArray[1] != typeof(TArg2))
            {
                throw new ArgumentException(string.Format("Expected 2nd type parameter of {0}, got {1}", typeArray[1].FullName, typeof(TArg2).FullName), "item2");
            }
            else if (typeArray[2] != typeof(TArg3))
            {
                throw new ArgumentException(string.Format("Expected 3rd type parameter of {0}, got {1}", typeArray[2].FullName, typeof(TArg3).FullName), "item3");
            }
            else if (typeArray[3] != typeof(TArg4))
            {
                throw new ArgumentException(string.Format("Expected 4th type parameter of {0}, got {1}", typeArray[3].FullName, typeof(TArg4).FullName), "item3");
            }

            InternalFire(new Transition<TArg1, TArg2, TArg3, TArg4>(this, trigger, this.State, item1, item2, item3, item4));
        }

        /// <summary>
        /// Return a string representation of the machine, which will detail the current state.
        /// </summary>
        /// <returns>A string representation of the machine</returns>
        public override string ToString()
        {
            StringBuilder sb = new StringBuilder();
            sb.Append("State = ");
            sb.Append(this.State);
            sb.Append(", ");
            sb.Append("PermittedTriggers = {");
            var permittedTriggers = this.PermittedTriggers;
            if (permittedTriggers.Count > 0)
            {
                bool first = true;
                foreach (var trigger in permittedTriggers)
                {
                    if (first)
                    {
                        sb.Append(' ');
                        first = false;
                    }
                    else
                    {
                        sb.Append(", ");
                    }
                    sb.Append(trigger);
                }
            }
            sb.Append(" }");
            
            return sb.ToString();
        }

        #endregion

        #region Private methods

        private static void InvokeAll<T>(Action<T>[] actions, T arg)
        {
            int length = actions.Length;
            if (length == 0)
            {
                return;
            }

            for (int i = 0; i < length; i++)
            {
                actions[i].Invoke(arg);
            }
        }

        private void InternalFire(Transition transition)
        {
            TTrigger trigger = transition.Trigger;
            TState currentState = transition.PreviousState;

            StateData currentData;
            TState newState = default(TState);
            bool throwError;

            if (!_stateToData.TryGetValue(currentState, out currentData))
            {
                throwError = true;
            }
            else if (currentData.Ignores.Contains(trigger, _triggerComparer))
            {
                return;
            }
            else
            {
                KeyValuePair<TState, Func<Transition, bool>> triggerWithGuard;
                if (currentData.Transitions.TryGetValue(trigger, out triggerWithGuard))
                {
                    transition.NewState = newState = triggerWithGuard.Key;
                    if (triggerWithGuard.Value != null && !triggerWithGuard.Value(transition))
                    {
                        return;
                    }
                    throwError = false;
                }
                else
                {
                    KeyValuePair<Func<Transition, TState>, Func<Transition, bool>> chooserWithGuard;
                    if (currentData.DynamicTransitions.TryGetValue(trigger, out chooserWithGuard))
                    {
                        if (chooserWithGuard.Value != null && !chooserWithGuard.Value(transition))
                        {
                            return;
                        }

                        newState = chooserWithGuard.Key(transition);
                        if (newState == null)
                        {
                            throw new InvalidOperationException(
                                string.Format(
                                    "Dynamic transition by state {0} on trigger {1} returned null",
                                    currentState,
                                    trigger));
                        }
                        transition.NewState = newState;
                        throwError = false;
                    }
                    else
                    {
                        throwError = true;
                    }
                }
            }

            if (throwError)
            {
                if (_unhandledTriggerHandler != null)
                {
                    _unhandledTriggerHandler(transition);
                }
                else
                {
                    throw new ArgumentException(string.Format("Cannot transition from state {0} with trigger {1}", currentState, trigger));
                }
                return;
            }

            InvokeAll(currentData.OnExits, transition);
            Action<Transition>[] actions;
            if (currentData.OnExitTos.TryGetValue(trigger, out actions))
            {
                InvokeAll(actions, transition);
            }

            State = newState;

            StateData newData;
            if (_stateToData.TryGetValue(newState, out newData))
            {
                InvokeAll(newData.OnEntries, transition);
                if (newData.OnEntryFroms.TryGetValue(trigger, out actions))
                {
                    InvokeAll(actions, transition);
                }
            }
        }

        #endregion
    }
}
